在前一篇文章中,我們瞭解了怎麼使用 _app.tsx
撰寫共用 layout 的 component,由於 App 是一個頂層的 component,每個頁面都會執行 App 裡面的程式碼,而我們可以在 _app.tsx
檔案中覆寫預設 App 的行為。
如果想要重複利用一些 layout 時,在 React 中我們經常使用的技巧包括 composition、HOC 等等,而通常這寫技巧都會保留這些 layout 的狀態,在切換頁面時並不會刷新。如果我們想要在 Next.js 中實現頁面共用 layout 的模式,唯一可行的方式就是在 _app.tsx
中撰寫共用 layout 的邏輯,因為如果把邏輯撰寫在頁面中,在切換頁面時整個 UI 都會被重新渲染,而狀態當然也不會被保留,會讓體驗回到像是 10 年前的網站。
如果只能在 _app.tsx
中共用 layout 的邏輯,就會衍生出一些實作上的問題:
_app.tsx
的程式碼難以理解以下我們來看幾個案例,漸進式了解怎麼在 Next.js 建立共用的 layout 邏輯,而且可以在切換頁面時仍然可以保留前個頁面的狀態。
首先,我們要建立兩個 layout 的共用元件,分別為 <Layout />
與 <ProductsLayout />
,這兩個元件都會放置於 components/
資料夾中。
第一個元件包 <Layout />
含了兩個 <Link />
可以用來切換兩個頁面,且這個元件包含了一個輸入匡,我們將會用這個輸入匡來測試如何在切換頁面時仍然可以包流狀態,這個元件以 composition 的方式建構,最後會渲染 children
。
import Link from "next/link";
import { FC, useState } from "react";
const Layout: FC = ({ children }) => {
const [keyword, setKeyword] = useState("");
return (
<div>
<Link href="/">home</Link>
<Link href="/products">product</Link>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
{children}
</div>
);
};
export default Layout;
另一個共用 layout 的元件是 <ProductsLayout />
,這個元件是 /products
會使用到的元件,而這個元件內還包含了 <Layout />
,為第一個建立的共用 layout 元件,這個元件的構成很簡單,只有渲染一個「product layout」的字串,以及 children
的內容。
import { FC } from "react";
import Layout from "./Layout";
const ProductsLayout: FC = ({ children }) => {
return (
<Layout>
<div>product layout</div>
{children}
</Layout>
);
};
export default ProductsLayout;
我們先來試試看一種基本上在 Next.js 不可行的方式,亦即在切換頁面時 layout 中的狀態一定會消失。在 pages/index.tsx
裡面使用 <Layout />
這個共用元件,並且單純渲染 You are in /
的字串內容。
// pages/index.tsx
import { NextPage } from "next";
import Layout from "@/components/Layout";
const Home: NextPage = () => {
return (
<Layout>
<div>You are in /</div>
</Layout>
);
};
export default Home;
而在 pages/products.tsx
裡面使用 <ProductsLayout />
這個用元件,並且單純渲染 You are in /products
的字串內容。
// pages/products.tsx
import { NextPage } from "next";
import ProductsLayout from "@/components/ProductsLayout";
const Products: NextPage = () => {
return (
<ProductsLayout>
<div>you are in /products</div>
</ProductsLayout>
);
};
export default Products;
如果 <Layout />
裡面只是單純的靜態內容,沒有讓 React 維護狀態,例如沒使用 useState
,這種建構頁面的方式是沒有問題的,而且程式碼也很直覺,不會很難維護。
但是缺點是在切換頁面時 layout 裡面的狀態完全不能夠保存,像是 layout 裡面包含 tab、 input 之類的會跟使用者互動的元件,在切換頁面後就會回到預設值,會讓使用者體驗非常地不好。
.layout
於頁面上,並於 App 中渲染 ?接下來,我們看另外一種抽象共用 layout 元件的方式,這種模式將是抽象 layout 元件變成使用注入的方式傳遞到 _app.tsx
中,這樣寫的好處是之後新增頁面時不需要每次都來維護這份 _app.tsx
,你能想像每次新增頁面時會需要使用大量的 if-else
判斷目前需要使用哪個 layout 嗎?
使用這種模式的情況下,每個頁面都只需要維護自己的 layout,新增一個新的頁面也只需要在新頁面中增加像是 Page.layout = layout
這種寫法,就可以讓該頁面使用共用的 layout 元件。
看起來很棒 ?
import { AppProps } from "next/app";
function MyApp({ Component, pageProps }: AppProps) {
const Layout = Component.layout || ((page) => <div>{page}</div>);
return (
<Layout>
<Component {...pageProps}></Component>
</Layout>
);
}
export default MyApp;
接下來看到前面看到的 pages/index.tsx
的程式碼,原本 <Layout />
是用 composition 的方式放在 component 裡面,現在將 layout 抽離出來,變成使用 Home.layout
注入用的 layout 元件。
// pages/index.tsx
import { NextPage } from "next";
import Layout from "@/components/Layout";
const Home: NextPage = () => {
return <div>You are in /</div>;
};
Home.layout = Layout;
export default Home;
以同樣的邏輯修改 pages/products.tsx
中的程式碼邏輯,把 <ProductsLayout />
這個元件抽離出來,變成用 Products.layout
的方式注入 layout 元件。
// pages/products.tsx
import { NextPage } from "next";
import ProductsLayout from "@/components/ProductsLayout";
const Products: NextPage = () => {
return <div>you are in /products</div>;
};
Products.layout = ProductsLayout;
export default Products;
看起來是一個很棒的 pattern,將共用 layout 的邏輯從 component 中抽離出來,讓 component 可以專注在自己的邏輯上,不會被額外的程式碼混淆。
實際上這個是一個錯誤的範例 ?,因為它不能夠解決在切換頁面時造成狀態不保留的問題。但也許讀者們會有些疑惑究竟是什麼原因造成狀態不保留,layout 已經抽象至 App 的層級,不論是 <Layout />
或 <ProductsLayout />
,裡面的 <Layout>{...}</Layout>
都是在最外層,應該是沒問題才對?
這裡要談到 React 的 component tree 與 reconciliation,以 ProductsLayout 為例,在 component tree 裡面會多出一個 ProductsLayout
的層級,而 Layout
會是 ProductsLayout
底下的節點。
在 React 中的 reconciliation 階段會比對同一層級的節點,而 ProductsLayout 跟 Layout 明顯是不一樣的節點,因此在切換頁面時,該節點以下的節點都會直接被砍掉,換上新的節點,所以因為這個情況在 layout 中的狀態才不能被保留。
.getLayout
於頁面上,並於 App 中渲染 ?以下終於要來介紹在 Next.js 中共用 layout 正確的方法 ?,基本上模式會與 .layout
的模式很像,都是抽離注入 layout 的邏輯,然後在 _app.tsx
取出 layout 並渲染元件。
不一樣的在於原本 .layout
傳入的是一個元件,但是我們在上面的範例中了解到 React 對一個 component 會在 component tree 中新增一個節點,因此在切換頁面時會因為節點不一樣在 reconciliation 被砍掉。
所以為了解決這個問題,使用另一種方式,以宣告 function 的方式定義共用 layout 的邏輯,如下方的 getLayout
,這不再是一個 component,也就不會在 component tree 中多一個節點。
// components/Layout.tsx
export const getLayout = (page) => <Layout>{page}</Layout>;
而 components/ProductsLayout.tsx
的內部程式碼也要做一些修改,原本 <Layout />
放在元件裡面,這此改用 getLayout
的方式一層一層地包裹著另一個 getLayout
,如此一來就能夠以不包含 component 節點的情況下共用 layout。
// components/ProductsLayout.tsx
import { FC } from "react";
import { getLayout as getBasicLayout } from "./Layout";
const ProductsLayout: FC = ({ children }) => {
return (
<div>
<div>product layout</div>
{children}
</div>
);
};
export const getLayout = (page) =>
getBasicLayout(<ProductsLayout>{page}</ProductsLayout>);
export default ProductsLayout;
由於共用 layout 的邏輯以 getLayout
的方式注入,所以在 _app.tsx
裡面也改從 Component.getLayout
取得頁面中相對應的 layout,最後將 getLayout
包裹在外面渲染內部的元件。
// pages/_app.tsx
import { AppProps } from "next/app";
function MyApp({ Component, pageProps }: AppProps) {
const getLayout = Component.getLayout || ((page) => page);
return getLayout(<Component {...pageProps}></Component>);
}
export default MyApp;
而在 pages/index.tsx
裡面原本的 .layout
也改成用 .getLayout
的方式注入 layout。
// pages/index.tsx
import { NextPage } from "next";
import { getLayout } from "@/components/Layout";
const Home: NextPage = () => {
return <div>You are in /</div>;
};
Home.getLayout = getLayout;
export default Home;
同樣地,在 pages/products.tsx
裡面原本的 .layout
也改成用 .getLayout
的方式注入 layout。
// pages/products.tsx
import { NextPage } from "next";
import { getLayout } from "@/components/ProductsLayout";
const Home: NextPage = () => {
return <div>you are in /products</div>;
};
Home.getLayout = getLayout;
export default Home;
接下來你可以重新運行網頁,在 layout 中的狀態在切換頁面時順利被保留了,原本在 input 中輸入的文字,在切換頁面後都會被清空,但是透過 .getLayout
這個 pattern,讓保留狀態的得以被實現。
再來,以 /products
這個頁面為例,我們透過 React devtool 看到 MyApp 底下第一層即是 Layout
,並不會像 .layout
的模式會再增加一層 component 的節點,因此在切換頁面時,Layout 這個節點是一樣的,所以在 reconciliation 才不會被當作是不同的節點被砍掉。
使用 TypeScript 時,我們必須為 getLayout
與 AppProps
重新定義型別,如果你在嘗試跟著一起撰寫上述的程式碼時,TypeScript 會報錯描述 getLayout
並不存在,所以需要修改 App 與每一個頁面中所引用的型別。
// next.d.ts
import { NextPageWithLayout } from "next";
import { AppProps } from "next/app";
declare module "next" {
type NextPageWithLayout = NextPage & {
getLayout?: (page: ReactElement) => ReactNode;
};
}
declare module "next/app" {
type AppPropsWithLayout = AppProps & {
Component: NextPageWithLayout;
};
}
在 App 中原本會使用 next/app
的 AppProps
,但是 Component
裡面沒有 getLayout
這個屬性,所以會過不了 ts compile,因此透過 AppPropsWithLayout
覆寫原本的 AppProps
,讓 Component
裡面多一個 getLayout
的屬性。
// _app.tsx
import { AppPropsWithLayout } from "next/app";
function MyApp({ Component, pageProps }: AppPropsWithLayout) {
const getLayout = Component.getLayout || ((page) => page);
return getLayout(<Component {...pageProps}></Component>);
}
export default MyApp;
而在頁面中原本使用的是 next
的 NextPage
,與 App 是同樣的理由, NextPage
裡面沒有 getLayout
這個屬性,所以改用 NextPageWithLayout
取代 NextPage
,如此一來就可以注入 layout 至頁面上了。
// pages/index.tsx
import { NextPageWithLayout } from "next";
import { getLayout } from "@/components/Layout";
const Home: NextPageWithLayout = () => {
return <div>You are in /</div>;
};
Home.getLayout = getLayout;
export default Home;